feat(engine): Rich terminal UI + core domain hardening#1
Conversation
Captures the contrai-analyzer probability + bidding stack as it stands today: SuitSlot/Rank enums, frozen Card dataclass, Hand/Deck, ProbabilityEngine (with method groups), BiddingEvaluator, BidSuggestion. Annotated as deliberately independent of contrai-core per CLAUDE.md §2 item 4 — SuitSlot is a suit-agnostic abstraction for the combinatorial math, not a duplicate of core's Suit enum.
Captures the Playwright spectator flow currently implemented in packages/contrai-scraper/main.py: launch → login → mode navigation (Online → Spectator → Contrée) → table discovery via #tournamentMatchInfo → identify players via #nord/#sud/#est/#ouest → poll #tour every 1s for round changes. FUTURE LOGIC (observe_bidding, observe_gameplay, SQLite persistence, DB de-duplication of already-scraped players) shown as dashed <<future>> arrows + grouped block referencing main.py:105-108, plus a greyed-out SQLite participant per CLAUDE.md §2 item 5 / §7.
Replaces docs/diagrams/README.md with docs/diagrams/index.md (the filename MkDocs nav already references at mkdocs.yml:117) and rewrites the content to: - Drop the manual 'plantuml -tpng / mmdc' rendering workflow from the Rendering section. MkDocs renders both PlantUML (via the plantuml_markdown extension, format: svg) and Mermaid (via mkdocs-mermaid2-plugin) inline at site-build time; the manual CLI invocations are now documented only as an optional standalone PNG fallback for slides, the LaTeX report, or offline preview, and the rendered PNGs are no longer committed. - Embed the two Phase 1 diagrams (class_analyzer.puml, seq_scraper.puml) via the plantuml-markdown 'source=' directive so .puml stays the canonical source of truth. - Document the per-package colour convention (core blue, engine orange, analyzer green, scraper purple) reused across diagrams. - Replace the previous baseline-diagrams TODO with a roadmap pointer to the deferred Phase 2 set (class_core, class_engine, class_workspace, seq_round, seq_bidding, seq_trick). Note: requires 'base_dir: docs/diagrams' on the plantuml_markdown extension in mkdocs.yml for 'source=' lookups to resolve; that line sits in the still-untracked mkdocs.yml and is left for separate commit.
Initial MkDocs configuration for the ContrAI docs site: - Material theme (light/dark toggle, Inter + JetBrains Mono fonts). - mkdocs-static-i18n with en (default) and fr locales — note: the navigation.instant feature is intentionally omitted because it is incompatible with i18n's language switcher. - mkdocstrings (Python handler, Google docstring style) for API reference pages. - mkdocs-mermaid2-plugin for inline Mermaid diagrams. - plantuml_markdown extension (format: svg) for inline PlantUML diagrams. - pymdownx superfences/highlight/tabbed/details/arithmatex, MathJax for LaTeX-style math. Nav covers Home, Architecture, the four packages (Engine, Core, Analyzer, Scraper) each with an Overview + API reference page, an AI Ladder section, and a Diagrams page.
Without base_dir set, the plantuml_markdown extension defaults to resolving 'source=' paths relative to CWD (the project root for 'mkdocs build' / 'mkdocs serve'), which makes embeds like ```plantuml source="class_analyzer.puml"``` fail with 'Cannot find external diagram source: class_analyzer.puml'. Setting base_dir to docs/diagrams lets the markdown reference each workspace-wide diagram by its bare filename, matching the canonical storage location for workspace-wide diagrams per CLAUDE.md §5. Package-local diagrams (under packages/<pkg>/) will need a path that walks back out — fine for the rare case.
Phase 1's smoke test embedded both diagrams here as a gallery. On reflection per-package placement is better for discoverability (the diagram lives next to the docs that describe its code) and matches CLAUDE.md §5 (package-local diagrams sit next to the doc that references them). Replaces the gallery with a conventions hub: - Two-tool policy summary (PlantUML for class/sequence, Mermaid for everything else). - Colour-convention table — per-package palette plus stub/future styling (light backgrounds, kept printable/report-friendly). - Rendering instructions reflecting the MkDocs build-time pipeline (plantuml_markdown + mkdocs-mermaid2-plugin), with the manual CLI workflow documented as an optional standalone-PNG fallback only. - Conventions: .puml/.mmd sources stay in docs/diagrams/ (single canonical location, base_dir resolves there from any topical page); embeds live on the package overview / architecture page; kind-prefixed filenames; honest portrayal via <<stub>>/<<future>> stereotypes. - Catalogue table — diagram → kind → scope → source → embed location → status, listing both shipped Phase 1 diagrams and the deferred Phase 2 set.
Add a 'Class structure' section embedding class_analyzer.puml from docs/diagrams/ (via plantuml_markdown 'source=' with base_dir set to docs/diagrams). Includes a one-line caption pointing readers at the deliberate independence from contrai-core (SuitSlot is a suit-agnostic abstraction for combinatorial math, not a duplicate of core's Suit enum) and a cross-link to the diagrams conventions hub. Note: this commit also folds in the staged rename of docs/packages/analyzer.md → docs/analyzer/index.md (staged before this session as part of the broader docs/ reorg). git commit --only on the destination path picks up the rename alongside the content modification.
Add seq_scraper.puml from docs/diagrams/ under 'Current flow (v1)' via plantuml_markdown 'source=' (base_dir: docs/diagrams). Adds a short caption mapping the diagram's dashed <<future>> arrows to the FUTURE LOGIC comment block at main.py:105-108 (observe_bidding, observe_gameplay, SQLite persistence, DB de-duplication of already-scraped players). Note: this commit also folds in the staged rename of docs/packages/scraper/README.md → docs/scraper/index.md (staged before this session as part of the broader docs/ reorg). git commit --only on the destination path picks up the rename alongside the content modification. The associated screenshots/ rename (also staged) stays out — different pathspec.
Full contrai-core domain model: Suit / Rank enums (incl. NO_TRUMP) with the CARD_SUITS module constant note, Card (with the four pre-computed points_normal/trump and order_normal/trump attributes), Deck, Hand (list-compatible API + query helper groups), Team, BasePlayer, the full Bid hierarchy (PassBid / ContractBid / DoubleBid / RedoubleBid) with BidValidator marked as <<utility>>, Contract, Trick, plus InvalidPlayerCountError / InvalidCardCountError inheriting from a stdlib ValueError boundary element. Honest portrayal: - Contract.get_defending_team() flagged as 'TODO: currently None' per the implementation comment. - ContractBid VALID_VALUES = [80..160, 'Capot'] shown verbatim (with the Capot → 250 numeric mapping noted). .png committed alongside .puml so the diagram is browsable offline without spinning up 'mkdocs serve'. Re-render whenever the .puml changes (plantuml -tpng docs/diagrams/class_core.puml).
Add a 'Class structure' section between the Module map and Consumers, embedding class_core.puml from docs/diagrams/ via plantuml_markdown 'source=' (base_dir: docs/diagrams). One-line caption flags the known Contract.get_defending_team() TODO so readers don't get the wrong idea from the diagram alone. Note: this commit also folds in the staged rename of docs/packages/core.md → docs/core/index.md (staged before this session as part of the broader docs/ reorg).
Engine model layer plus a deliberately honest portrayal of the still-partial MVC: - Player hierarchy: abstract Player (engine) extends BasePlayer (drawn as a contrai-core boundary element in core blue, stereotype <<from contrai-core>>) ← HumanPlayer (stubbed: choose_bid / choose_card return None) and AiPlayer (full bidding + card-play strategy). AiPlayer's ~25 private strategy helpers collapsed into a <<strategy>> note. BIDDING_TABLE noted as 9 levels 80-160 with no Capot row (correcting a prior misread of the table). - Game / Round shown with their public + private API surfaces. Annotated open question: Round._determine_trick_winner duplicates Trick.get_winner from contrai-core. - GameController and CliView rendered in stub grey palette with <<stub>> stereotypes. Notes flag the reality: GameController references undefined 'pygame' and isn't wired to Game/Round at all; cli_view.py is an empty file, so Round's view.request_* branches are guarded but unreachable. - MVC arrows (Controller→Game, Round→CliView) drawn dashed with <<would drive>> / <<would consult>> stereotypes to signal they're the intended wiring, not the current state. .png committed alongside .puml for offline preview.
High-level Game.manage_round flow with ref blocks pointing at the two zoom diagrams (seq_bidding, seq_trick) for detail. Captured today's behavior, not the aspirational MVC: - Setup phase: next_dealer is random on round 0, rotate +1 thereafter; shuffle on round 0 / cut on subsequent; deal puts the dealer last. - Bidding phase delegates to manage_bidding which returns Contract or None. - Failed-contract path: all players passed → handle_failed_contract puts every hand back into the deck and returns zero scores. - Trick-taking: play_all_tricks loops play_trick 8×. - Scoring: card_points summed per team with belote +20 and dix-de-der +10, compared to contract.value (Capot ≥ 162 else ≥ value), double/redouble multiplier applied. Orange engine palette with the Deck participant rendered in core blue as a <<from contrai-core>> boundary element. .png committed alongside .puml for offline preview.
Zoom on Round.manage_bidding — the bid loop, validation via
BidValidator, the legacy-format conversion bridge, and the multi-
condition termination.
Honest portrayal of the human-input branch: view.request_bid_action
is shown dashed with <<not implemented>> annotation because CliView
is empty, so the (view ≠ None AND player.is_human) branch in Round
is reachable in principle but never fires today.
Termination conditions captured precisely:
- Inner-loop break when passes_count >= 3 AND len(bid_objects) > 3
AND at least one non-pass bid exists.
- Outer-loop break: same condition, OR the first-round wipe (last
4 bids all PassBid).
Contract construction: get_last_contract → has_double / has_redouble
checks → new Contract(...) | None.
A note at the bottom documents the legacy wire format ('Pass' /
'Double' / 'Redouble' / (value, suit)) and the two converters that
keep Player.choose_bid happy with tuples while internal state uses
proper Bid objects.
.png committed alongside .puml for offline preview.
Zoom on Round.play_trick — leader determination → 4-player loop with legality enforcement → winner + bookkeeping. Captures two subtle behaviours that surprise readers of the code: - Trick() is built without a trump_suit argument. Round therefore determines the winner via its own _determine_trick_winner using self.contract.suit, bypassing Trick.get_winner() that lives on contrai-core. Flagged as a note on the diagram. - After choose_card returns an illegal card (not in playable_cards or not in hand), Round silently falls back to playable_cards[0] rather than re-asking or raising. Bookkeeping captured precisely: last_trick_winner update, append to tricks + team_tricks, and the reverse-order deck.add_cards call (last card played returns first to the deck). Legality rules (SF-09 / SF-10) inlined as a long note: follow suit if possible, partner-led short-circuit, must-trump / must-overtrump when partner is not leading, discard fallbacks. view.request_card_action shown dashed as <<not implemented>> — CliView empty, same situation as seq_bidding. .png committed alongside .puml for offline preview.
Add three new sections to the engine overview page:
- 'Class structure': embeds class_engine.puml with a caption that
spells out which Player subclasses are real vs stubbed and what
the grey GameController / CliView boxes actually mean today.
- 'Round lifecycle': embeds seq_round.puml as the headline overview.
- Two collapsible details blocks (pymdownx.details) under the round
overview: 'Bidding cycle zoom' embeds seq_bidding.puml,
'Single trick zoom' embeds seq_trick.puml. Collapsed by default
so the page reads as a summary first; readers drill in only if
they want detail.
Also small copy fixes in adjacent prose:
- AI players section: clarify the BIDDING_TABLE is 80-160 only with
no Capot row (matching the diagram and the actual code), and point
at the diagram's collapsed <<strategy>> helpers note.
- Open work: replace the TODO line ('MVC class diagram + round-flow
sequence diagram') with a forward-reference to the <<stub>> boxes
now visible on the class diagram.
Note: this commit also folds in the staged rename of
docs/packages/engine.md → docs/engine/index.md (staged before this
session as part of the broader docs/ reorg).
Bird's-eye view of the four packages with all four palettes appearing at once (core blue, engine orange, analyzer green, scraper purple) plus the grey stub palette for GameController / CliView. Each package shows 4-8 headline types; the engine pulls only its own elements + a <<extends>> arrow into core's BasePlayer. Cross-package dependency direction is the headline: - engine <<uses>> core: solid dashed-dependency arrow with the imported types listed (Card, Suit, Rank, Bid, Contract, Trick, Team, Deck). - engine.Player <<extends>> core.BasePlayer: solid inheritance. - scraper <<future>> core: dashed, captures the planned materialization of observed games into core types (CLAUDE.md §2 item 5). - analyzer ↛ core: deliberately NO arrow, with a banner note explaining why SuitSlot is suit-agnostic (CLAUDE.md §2 item 4). A second banner note on the engine package describes the planned multiplayer web server (FastAPI + WebSockets) that doesn't live in this repo yet but will consume engine + AI ladder models. Scraper rendered as a single <<module>> box for main.py since the package is procedural (no classes today). .png committed alongside .puml for offline preview.
Add a 'Package map' section between Workspace layout and Shared types, embedding class_workspace.puml. Caption summarises the cross-package edges visible on the diagram (engine extends core, scraper future-arrow into core, analyzer deliberately disconnected, multiplayer-web-server planned note) so the page reads coherently even before the reader expands the SVG. The existing ASCII dependency-direction block stays for fast scan below; the diagram is the rich version above.
Phase 2 is shipped — every diagram now has a committed PNG render alongside its .puml source so contributors can preview diagrams in a file browser / IDE / slides / the LaTeX report without running 'mkdocs serve'. Backfill the two Phase 1 PNGs that were missing (class_analyzer.png, seq_scraper.png) so the policy is uniform across all 8 diagrams. docs/diagrams/index.md changes: - Rendering section: rewrite the 'These renders are optional and not committed' line. New policy is that PNGs are committed alongside each .puml and must be re-rendered + committed in the same atomic commit whenever the source changes. MkDocs still re-renders from .puml (the canonical source); the PNG exists only for offline preview. - Catalogue table: replace the Phase-2-planned rows with completed Phase 2 entries linking to each diagram's source, PNG, and embed location. The eight diagrams now cover every package overview + the architecture page; the engine page hosts four diagrams (class + 3 sequences) with the zooms collapsed under pymdownx.details blocks. This closes the Phase 2 work captured in the plan file.
Flatten the docs/ tree so MkDocs nav can reference each section by a
clean top-level directory:
- docs/ai/{rl,rule_based,supervised}.md → docs/ai-ladder/*.md
- docs/packages/analyzer.md → docs/analyzer/index.md
- docs/packages/core.md → docs/core/index.md
- docs/packages/engine.md → docs/engine/index.md
- docs/packages/scraper/README.md → docs/scraper/index.md (+ screenshots)
- delete the old docs/README.md (replaced by docs/index.md as MkDocs
homepage, committed separately)
This mirrors the nav layout in mkdocs.yml (Home / Architecture /
Engine / Core / Analyzer / Scraper / AI Ladder / Diagrams) and lets
each section have its own subdirectory with sibling files (api.md,
screenshots/, etc.) without the docs/packages/ extra level of
indirection.
Complete the docs site setup referenced by mkdocs.yml:
- docs/index.md: homepage (TODO placeholder for now).
- docs/ai-ladder/index.md: AI ladder section index (TODO placeholder).
- docs/{core,engine}/api.md: mkdocstrings directives that auto-generate
the API reference from each package's Google-style docstrings.
- docs/{analyzer,scraper}/api.md: explanatory stubs — analyzer is a
Streamlit app (not a library) and scraper currently ships only
entry-point scripts (no importable package), so neither exposes a
stable namespace for mkdocstrings yet. The stubs document why and
point to the trigger condition for replacing them with directives.
- docs/assets/: logo.svg and the (currently committed as) flavicon.png
referenced by mkdocs.yml's theme block.
- docs/javascripts/mathjax.js: MathJax config referenced by mkdocs.yml.
- docs/stylesheets/extra.css: theme overrides referenced by mkdocs.yml.
- README.md: add a Docs site section explaining 'uv run mkdocs serve /
build' so contributors discover the site.
- .gitignore: ignore site/ (MkDocs build output).
Canonical reference for the rules, terminology, and community conventions of Contrée, independent of any software implementation. Per CLAUDE.md §0 this is the document to consult whenever a question touches game semantics (legal moves, bidding table, Belote bonus, FR↔EN terminology, etc.) before reading or writing engine code. Lives at the workspace root rather than under docs/ because it's referenced from CLAUDE.md and from outside the MkDocs site as well.
Run pytest against contrai-core and contrai-engine on every push to main and every PR targeting main. Matrix strategy with fail-fast disabled so a break in one package doesn't mask a break in another. Sets up uv with lockfile-keyed caching, installs the workspace Python via 'uv python install' (no version arg, lets uv pick from requires-python / .python-version), and syncs every workspace member with --all-packages --all-extras so contrai-engine's editable dependency on contrai-core resolves cleanly. Each matrix job then runs 'uv run --package <name> pytest' for a clean per-package status check in the PR UI. Concurrency group cancels superseded runs on the same ref to save CI minutes.
Convert the workspace from purely-virtual into a proper [project] with a docs dependency group, fixing the CLAUDE.md §10 'uv sync ergonomics' item: - Add a top-level [project] table (name: contrai-workspace, version: 0.0.0, requires-python: >=3.14) that depends on all four workspace members. A fresh 'uv sync' now installs every member in editable mode without the manual 'uv pip install -e packages/...' follow-up the README used to recommend. - Add [dependency-groups] 'docs' with mkdocs-material, mkdocstrings [python], mkdocs-mermaid2-plugin, plantuml-markdown, and mkdocs-static-i18n — the deps mkdocs.yml requires. - Set [tool.uv] default-groups = ['docs'] so 'uv sync' / 'uv run' pick up the docs deps without needing --group docs every time. - Register engine/analyzer/scraper under [tool.uv.sources] alongside the pre-existing contrai-core entry, so workspace resolution picks them up by name. uv.lock regenerated to capture the docs dep group transitive closure (~318 lines).
contrai-scraper currently ships only entry-point scripts (main.py, run.py) at the package root with no importable Python module under src/. Setuptools' flat-layout discovery trips on the two top-level .py files when no [tool.setuptools] section tells it which modules to package. Declare an empty py-modules list to suppress the discovery error without falsely claiming any importable surface. Comment in the file explains the situation and notes to revisit once the scraper grows a real src/contrai_scraper/ package.
Trump is round-level state carried by the Contract, not a property of an individual trick. Drop the unused Trick.trump_suit field (never set — the engine always built Trick() bare) and make get_current_winner take trump_suit as a required argument, mirroring Card.get_order/get_points. Removes the silent no-trump evaluation an omitted argument used to cause and fixes the stale `str` type hint to `Suit`.
Round._determine_trick_winner was a byte-for-byte copy of the comparison already on Trick.get_current_winner. Delete it and have play_trick delegate to core, passing the contract's trump suit — the same call _count_player_tricks and _get_playable_cards already use. Behaviour unchanged; full engine suite stays green.
Reflect that Trick no longer stores trump and that the engine delegates trick-winner determination to Trick.get_current_winner: update the core and engine package narratives, the core/engine class diagrams, and the single-trick sequence diagram (PNGs re-rendered).
…lity Card used default identity equality, so distinct instances of the same physical card compared unequal and Card was unhashable by value. This breaks the moment a Card is deep-copied or reloaded (scraper/ML replay). Convert Card to @DataClass(frozen=True, slots=True), matching the Bid precedent: equality/hash are now over (suit, rank). No __lt__ is added — strength stays parametric via get_order(trump_suit). Drop the cached points_*/order_* attributes (read only inside get_points/get_order) so the methods index the class dicts directly.
…tests
Card now compares by value, so the str()-multiset assertions and the
_ids((suit, rank)) tuple projection are obsolete: deck tests use
collections.Counter / set comparisons and round tests compare set(legal)
against {Card(...)} literals directly.
contrai-core and contrai-engine each ship a top-level tests package (both with their own __init__.py). Under pytest's default prepend import mode the two collide on sys.modules['tests'], so a root `uv run pytest` aborted while collecting the second package with "No module named 'tests.test_view'". Set --import-mode=importlib in the root [tool.pytest.ini_options]; pytest imports each test module under a path-derived name, so the per-package suites compose into one green workspace run (per-package runs are unaffected).
has_suit answers "do I hold any card of this suit" with a short-circuiting scan — cheaper than bool(cards_of_suit(...)) and the primitive the engine's lead-detection needs. has_card now delegates to `Card(suit, rank) in self`. Card became a frozen value object comparing by (suit, rank), so membership is the single source of truth for "do I hold this card" — no parallel field-by-field scan to drift.
Delete the untyped private helpers _count_cards_in_suit and _suit_has_rank; they were character-for-character copies of Hand.count_suit / Hand.has_card — exactly the ad-hoc re-implementation the Hand class exists to prevent. Repoint every caller (and the inline suit comprehensions) to count_suit / has_card / cards_of_suit. Behaviour is unchanged. Advances the Hand-adoption item in CLAUDE.md §10.
…racking Swap the lead-suit / trump-suit comprehensions in _get_playable_cards and _classify_play_violation for Hand.cards_of_suit, and the belote King+Queen scans for Hand.has_card. Pure expression swaps — the mirrored branch order between the two legality methods is untouched.
…led-up Scoring tables
…eError The bad-contract fallback in wire_to_bid relied on the ValueError umbrella to catch the InvalidContractError raised by ContractBid for an unknown value/suit. Catch the specific domain error instead so an unrelated ValueError from ContractBid surfaces as a loud failure rather than being silently downgraded to a Pass. Add TestWireToBid covering the keyword wires, the valid-tuple path, and both fallback branches.
When the declaring team wins all 8 tricks on an un-doubled numeric contract (an unannounced slam / grand slam), replace the 162-point trick pile with a flat 250 substitute: the declarer scores its contract value + 250 (+ belote), the defense scores nothing, and the dix de der is folded into the substitute. Taking every trick forces the contract made. Doubled/redoubled contracts keep their existing winner-takes-all 160 + C*M shape, and a defence sweep is unaffected. The round recap shows 250 in the Outcome "Trick points" row, an em-dash in "Last trick", and a "Slam" / "Grand Slam" tag to the right of the row explaining the substitute (Grand Slam when the bidder personally won all eight tricks).
The all-tricks contracts were string sentinels ("Slam"/"SoloSlam") inside a
ContractBid.value: int | str union, and the points they are worth (250/500)
were re-derived from those strings in three separate methods. That left 250/500
a magic number with no single owner and the value stringly-typed.
Introduce SlamLevel(Enum) whose members own their base value as data
(SLAM = 250, SOLO_SLAM = 500): the single source of truth for the points the
contract commits to, which also serves as the slam-family scoring substitute.
A plain Enum (not IntEnum) keeps the type distinct from int, so a Slam's value
can never be silently mistaken for card points in scoring arithmetic.
- ContractBid.value is now int | SlamLevel; VALID_VALUES ends with the two
members (order preserved so Auction.legal_actions stays monotonic);
get_numeric_value resolves via base_value.
- Contract.is_slam / is_solo_slam / is_slam_family switch to identity /
isinstance; get_base_points delegates to get_numeric_value;
get_slam_card_substitute reads base_value. No 250/500 literal remains in core.
- Auction legality uses isinstance(value, SlamLevel); the asymmetric
Slam -> SoloSlam block rule is unchanged.
- SlamLevel is exported from the package root.
- Tests migrated to the enum; the old string sentinels are now asserted
invalid, and TestSlamLevel covers base values, labels, and VALID_VALUES order.
…bstitute
Follows the core SlamLevel migration. The engine no longer threads "Slam" /
"SoloSlam" strings around; the AI still works in numerics internally and now
converts to/from SlamLevel members at the bid boundary.
- player.py: SLAM_NUMERIC / SOLO_SLAM_NUMERIC are sourced from
SlamLevel.{SLAM,SOLO_SLAM}.base_value and used directly in BIDDING_TABLE; the
wire bridge (_bid_value_numeric / _numeric_to_wire) converts between the table
numerics and SlamLevel members rather than strings.
- round.py: UNANNOUNCED_CAPOT_SUBSTITUTE now reads SlamLevel.SLAM.base_value, so
the undeclared-sweep substitute and a declared Slam's base share one source.
- rich_view.py: the human-bid parser returns SlamLevel members; the contract
display branches collapse to str(contract.value) (the enum's label); the
"nothing outranks a Slam" legality messages use isinstance.
- Engine tests migrated to SlamLevel, including the recap _StubContract stub.
No behavioural change: a numeric bid's value stays a plain int, and the slam
at-risk grid (500/1000/2000 and 1000/2000/4000) is unchanged.
Reflect the typed SlamLevel enum in the core class diagram: add the enum (with base_value / label), change ContractBid.value and Contract.value to int | SlamLevel, update VALID_VALUES and the get_numeric_value note, and add the ContractBid ..> SlamLevel dependency. PNG re-rendered in the same commit.
…announcedSlam enum
The undeclared all-tricks sweep was tracked as a stringly-typed Round attribute
(None / "slam" / "grand slam") and the View title-cased that sentinel to render
the explanatory recap tag. Replace it with a small UnannouncedSlam(Enum) whose
member value is the display label, so the tag is type-safe and the View no
longer re-derives the label.
This is deliberately separate from SlamLevel: an unannounced slam is a post-play
*outcome* on a numeric contract (scored on the numeric path with a flat 250
substitute), not a declared bid — different name ("Grand Slam" vs "Solo Slam"),
different scoring, and a meaningful None state. The Round.unannounced_capot
attribute and the UNANNOUNCED_CAPOT_SUBSTITUTE constant keep the domain term.
- round.py: add UnannouncedSlam{SLAM, GRAND_SLAM}; unannounced_capot is now
Optional[UnannouncedSlam], set to the matching member after a sweep.
- rich_view.py: the recap tag renders str(capot_label) directly (the member
value is already "Slam" / "Grand Slam") instead of .title()-casing a string.
- Tests migrated to the enum, plus a focused check on the member labels.
No behavioural change: the rendered tag and the 250 substitute are unchanged.
… points in recap Round recap Outcome table gains a Total row (trick points + last trick + belote) and renames "Trick points" to "Tricks points". Leading "+" signs are dropped from the Outcome bonus cells and every Scoring number. The Scoring "Round points" row now shows the score-contributing part only (round score minus contract), so a chuté or contré round dashes out the captured pile and keeps just the belote.
Add a blank line after the Tricks won row so the trick count reads apart from the point rows (Tricks points / Last trick / Belote / Total) that follow. A column rule there would wrongly imply a sub-total, so a blank line is used instead.
PR Summary by Qodofeat(engine): Rich terminal UI + core domain hardening (v0.1.0) Description
Diagram
High-Level Assessment
Files changed (81)
|
Code Review by Qodo
1. ALL_TRUMP rules missing
|
Summary
First playable release cut (v0.1.0): a Rich-based terminal UI for Contrée,
plus substantial hardening of the core domain model and the bidding/scoring
engine.
Highlights
CLI / View (contrai-engine)
contraiCLI (Round panel, hand panel,auction diamond, persistent event log, round-recap screens).
Bidding (contrai-core)
Bidvariantsfrozen as dataclasses (no more BidValidator).
the contract; correct freeze semantics after Double/Redouble.
Scoring & domain model
SlamLevel, unannounced-slam handling.Cardis now a frozen value object with (suit, rank) equality.Handquery API (has_suit/has_card) adopted across engine + AI.ContraiErrorbase,IllegalPlayError,InvalidContractError.AI play fixes
auto-pass when partner has doubled; no trump waste when partner is master.
Testing
uv run pytestgreen across contrai-core + contrai-engine.uv run contraismoke run per convention.